{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Предсказание оценок пользователя\n",
    "\n",
    "<b>Рекомендательные системы</b> являются основопологающим элементов многих современных web-сервисов. Начиная от социальных систей, где нам предлагают умную ленту до интернет-магазинов, где всегда можно увидеть несколько интересных сопутствующих предложений. <b>Ценность</b> такой системы довольная очевидна $-$ мы хотим удерживать пользователя на сервисе, чтобы он приносил нам больше прибыли от просмотра рекламы или покупки новых товаров.\n",
    "\n",
    "В этом проекте я разберу подзадачу построения рекоменадательной системы, а именно предсказание оценок пользователя для предмета, для этого изучу данные, выделю признаки и сравню несколько алгоритмов на качество.\n",
    "\n",
    "Для начала <b>формализуем задачу</b>:\n",
    "\n",
    "Имеется множество пользователей $U$ и множество предметов $I$. Для некоторых фильмов конкретный пользователь уже поставил оценку $r_{ui}$, надо предсказать оценку для всех остальных задач.\n",
    "\n",
    "На основе этих данных можно предлагать пользователю товары с наибольшей вероятной оценки, но это не всегда оптимально, так как существует множество сопутствующих факторов, поэтому задача сводится к задаче ранжирования, которая решается своими способами. Именно поэтому выделяется отдельная подзадача.\n",
    "\n",
    "Рассматривать же задачу мы будем на примере предсказывания оценок пользователя фильмам в онлайн-кинотеатре или сервисе, подобном [kinopoisk](https://www.kinopoisk.ru/).\n",
    "\n",
    "Данные мы возьмём от организации [GroupLens](https://grouplens.org/), которая собрала оценки $138493$ пользователей по $27278$ фильмам в период с $\\textit{09 января 1995}$ по $\\textit{17 октября 2016}$. В общей сложности было поставлено $20000263$ оценок и $465564$ тегов. Основными источниками данных послужили сервисы [imdb](https://www.imdb.com/) и [tmdb](https://www.themoviedb.org/?language=ru).\n",
    "\n",
    "Если у вас UNIX система и установлен kaggle API, то для скачивания данных выполните следующий блок. Так же можно скачать данные напрямую со соответствующей страницы на [kaggle](https://www.kaggle.com/grouplens/movielens-20m-dataset)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!kaggle datasets download -d grouplens/movielens-20m-dataset -p ./data/"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import warnings\n",
    "warnings.simplefilter('ignore')\n",
    "\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import seaborn as sns\n",
    "from tqdm import tqdm_notebook\n",
    "\n",
    "%pylab inline\n",
    "\n",
    "PATH_TO_DATA = './data/'"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Обзор данных"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "genome_scores = pd.read_csv(PATH_TO_DATA + 'genome_scores.csv', dtype={\n",
    "    'movieId': np.int32,\n",
    "    'tagId': np.int16,\n",
    "    'relevance': np.float32\n",
    "})\n",
    "genome_tags = pd.read_csv(PATH_TO_DATA + 'genome_tags.csv', index_col='tagId')\n",
    "link = pd.read_csv(PATH_TO_DATA + 'link.csv', index_col='movieId', dtype={\n",
    "    'imdbId': np.int32 # так как в tmdb содержатся значения NaN, пока не трогаем\n",
    "})\n",
    "movie = pd.read_csv(PATH_TO_DATA + 'movie.csv', index_col='movieId')\n",
    "rating = pd.read_csv(PATH_TO_DATA + 'rating.csv', parse_dates=['timestamp'], dtype={\n",
    "    'userId': np.int32,\n",
    "    'movieId': np.int32,\n",
    "    'rating': np.float32\n",
    "})\n",
    "tag = pd.read_csv(PATH_TO_DATA + 'tag.csv', parse_dates=['timestamp'], dtype={\n",
    "    'userId': np.int32,\n",
    "    'movieId': np.int32,\n",
    "    'tagId': np.int16\n",
    "})"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# в данной таблице содержится информация\n",
    "# о релевантности фильма и тега\n",
    "# (чем меньше значение, тем меньше подходит\n",
    "# данный тег к фильму)\n",
    "\n",
    "genome_scores.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# здесь можно посмотреть на значение\n",
    "# тега по его id\n",
    "\n",
    "genome_tags.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# как было сказано основной источник данных\n",
    "# это imdb и tmdb\n",
    "# поэтому в данной таблице можно увидеть\n",
    "# соответствие между id фильма в выборке\n",
    "# и id фильма на imdb и tmdb\n",
    "\n",
    "# как было отмечено при чтение данных, в колонке tmdbId\n",
    "# есть значения NaN, заменим их на -1\n",
    "# так как это категориальная колонка и значения -1 в ней нет\n",
    "# так же нам может понадобится информация,\n",
    "# что какого-то фильма нет на сайте tmdb\n",
    "\n",
    "link['tmdbId'] = link['tmdbId'].fillna(-1).astype(np.int32)\n",
    "link.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# здесь можно посмотреть информацию о каждом фильме\n",
    "# его название, год выпуска и жанры (разделены через |)\n",
    "\n",
    "movie.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# основная информация для нашей задачи,\n",
    "# по userId можно увидеть какие оценки\n",
    "# и каким фильмам ставил пользователь\n",
    "# так же для каждой оценки известно время,\n",
    "# когда её поставили\n",
    "\n",
    "rating.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# так же пользователи ставят фильмам\n",
    "# собственные теги, для которых также \n",
    "# известна временная отметка\n",
    "\n",
    "tag.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Как видно, данный набор представляет большие возможности для изучения и задача предсказания оценки лишь одна из возможных.\n",
    "\n",
    "Модели для предсказания оценок можно поделить на большие категории:\n",
    "1. Коллаборативная фильтрация $-$ используется информация о прошлом поведение пользователя и информация о других пользователях;\n",
    "2. Контентная фильтрация $-$ формирует рекомендации на основе ретроспиктивного анализа поведения пользователя, здесь уже может использоваться информация о предметах, информация о пользователях;\n",
    "3. Гибридные методы $-$ сочетание коллаборативной и контентной фильтрации.\n",
    "\n",
    "### первичный анализ\n",
    "Но перед тем как строить модели для начала проведём первичный анализ данных"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rating.shape"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rating['userId'].nunique(), rating['movieId'].nunique()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Как было отмечено в начале, имеется большое количество различных фильмов и пользователей, при этом вторых гораздо больше."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rating.isna().sum()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rating['rating'].describe()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Шкала оценок распределена от 0 до 5, но оценку 0 никто не ставит, минимум 0.5.<br>\n",
    "При это средняя и медианна оценка составили ~3.5"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rating['rating'].unique()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "шаг оценки 0.5, поэтому всего 10 различных вариантов"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rating.groupby('userId').agg({\n",
    "    'rating': [mean, std, median, min, max]\n",
    "}).describe()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Но если мы будем смотреть на пользователя по отдельности, то получим, что его средние показатели не совпадают с общими. Это наталкивает на мысль, что у каждого пользователя свои идеи об оценках, так для одного средняя оценка 2.5, а для другого 3.5, значит надо анализировать пользователей по отдельности."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rating.groupby('movieId').agg({\n",
    "    'rating': [mean, std, median, min, max]\n",
    "}).describe()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Аналогичная ситуация и для фильмов, есть бестеллеры, которые в целом хорошо оценивают, а есть шлак, которому ставят\n",
    "плохие оценки."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "genres = set()\n",
    "for cur_genres in movie['genres'].values:\n",
    "    genres = genres.union(cur_genres.split('|'))\n",
    "print(len(genres), list(genres))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В выборке присутствует 19 различных жанров + значение нет жанров, наверняка от жанра зависит средняя оценка по фильму"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ids_genres = [[] for _ in np.arange(20)]\n",
    "for (id, row) in tqdm_notebook(movie.iterrows(), total=movie.shape[0]):\n",
    "    for i, genre in enumerate(genres):\n",
    "        if genre in row[1]:\n",
    "            ids_genres[i].append(id)\n",
    "            \n",
    "for i, id in enumerate(ids_genres):\n",
    "    print('{} mean is {}'.format(\n",
    "        list(genres)[i],\n",
    "        rating.groupby('movieId')['rating'].mean()[id].mean()\n",
    "    ))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Посчитали среднее по каждому фильму, а потом по каждой категории, так как установили, что для каждого фильма своя средняя оценка<br>\n",
    "Как видим, для каждой категории также присуще своё среднее значение рейтинга, можно сделать вывод, что этот признак влияет на целевую переменную.\n",
    "\n",
    "Так же в название фильма указан год, что тоже быть хорошей фичей (данные были взяты с фильмами с 1995 по 2016 год)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "years = list(np.arange(1995, 2017))\n",
    "ids_years = [[] for _ in np.arange(len(years))]\n",
    "for (id, row) in tqdm_notebook(movie.iterrows(), total=movie.shape[0]):\n",
    "    for i, year in enumerate(years):\n",
    "        if str(year) in row[0]:\n",
    "            ids_years[i].append(id)\n",
    "            \n",
    "for i, id in enumerate(ids_years):\n",
    "    print('{} mean is {}'.format(\n",
    "        years[i],\n",
    "        rating.groupby('movieId')['rating'].mean()[id].mean()\n",
    "    ))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Здесь колебания среднего уже не такое значительное как у жанров, но всё же такой признак может влиять на целевую переменную.\n",
    "\n",
    "Вспомним про imdb и tmdb, у некоторых фильмов не было значений tmdb, это значит, что такого фильма нет (не было) в базе tmdb, посмотрим, влияет ли это на оценку:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "data = rating.groupby('movieId')['rating'].mean().reset_index()\n",
    "data['has_tmdb'] = data['movieId'].apply(lambda x: link.loc[x, 'tmdbId'] != -1)\n",
    "data.groupby('has_tmdb')['rating'].mean()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Практически не влияет.\n",
    "\n",
    "### визуальный анализ\n",
    "первичный анализ показал возможные направления решения, но давайте их подтвердим визуально.\n",
    "\n",
    "Для начала проверим, что средняя оценка каждого пользователя и фильма имеет большой разброс."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "x = rating.groupby('userId')['rating'].mean().index.values\n",
    "y = rating.groupby('userId')['rating'].mean().values\n",
    "sns.regplot(x, y)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "x = rating.groupby('movieId')['rating'].mean().index.values\n",
    "y = rating.groupby('movieId')['rating'].mean().values\n",
    "sns.regplot(x, y)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "get_user = lambda id, data: data[data['userId'] == id]['rating']\n",
    "fig, axes = plt.subplots(nrows=3, ncols=3, figsize=(16, 10))\n",
    "for i in np.arange(9):\n",
    "    sns.distplot(get_user(i + 1, rating), ax=axes[i // 3, i % 3])\n",
    "    axes[i // 3, i % 3].set_xlabel('ratings')\n",
    "    axes[i // 3, i % 3].set_ylabel(i)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Гипотеза подтвердилась, для каждого пользователя действительно своя средняя оценка, которая не приближенна к общей. Для фильмов распределение среднего уже больше похоже на общее среднее, но также имеет большой разброс.\n",
    "\n",
    "Можно попробовать улучшить положение, если нормализовать оценки, а именно: для каждого пользователя из его оценки вычесть его среднюю оценку. Как изменится средняя оценка тогда:\n",
    "$$\n",
    "    \\overline{r} = \\frac{r_1 + r_2 + \\ldots + r_n}{n}\n",
    "$$$$\n",
    "    \\overline{r'} = \\frac{(r_1 - \\overline{r}) + \\ldots + (r_n - \\overline{r})}{n} = \\overline{r} - \\frac{n \\times \\overline{r}}{n} = 0\n",
    "$$\n",
    "Значит среднее станет 0, в нашей интерпретации это означает, что пользователи имеют одинаковое понимание средней оценки."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "figure(figsize=(30, 7))\n",
    "sns.barplot(list(genres), [len(i) for i in ids_genres])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "data = pd.DataFrame(rating.groupby('movieId')['rating'].mean()).reset_index()\n",
    "for genre in tqdm_notebook(genres, total=len(genres)):\n",
    "    data[genre] = data['movieId'].apply(lambda x: int(genre in movie.loc[x, 'genres']))\n",
    "data.set_index('movieId', inplace=True)\n",
    "sns.heatmap(data.corr('spearman'))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Существует корреляция между жанрами и рейтингом, но следует отметить, что и многие жанры между собой коррелируют, хотя значение корреляции мало."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "figure(figsize=(30, 7))\n",
    "sns.barplot(years, [len(i) for i in ids_years])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "data = pd.DataFrame(rating.groupby('movieId')['rating'].mean()).reset_index()\n",
    "for year in tqdm_notebook(years, total=len(genres)):\n",
    "    data[year] = data['movieId'].apply(lambda x: int(str(year) in movie.loc[x, 'title']))\n",
    "data.set_index('movieId', inplace=True)\n",
    "sns.heatmap(data.corr('pearson'))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Корреляция крайне мала.\n",
    "\n",
    "Посмотрим, сколько раз голосовали разные люди:\n",
    "Так как значений очень много, то разделим на несколько категорий:\n",
    "1. \\>1000;\n",
    "2. 500-1000;\n",
    "3. 100-500;\n",
    "4. 10-100;\n",
    "5. 1-10;"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def cat(x):\n",
    "    if x > 1000:\n",
    "        return 0\n",
    "    elif x > 500:\n",
    "        return 1\n",
    "    elif x > 100:\n",
    "        return 2\n",
    "    elif x > 10:\n",
    "        return 3\n",
    "    else:\n",
    "        return 4\n",
    "count = lambda x: cat(len(x))\n",
    "    \n",
    "data = rating.groupby('userId').agg({'rating': count}).astype(int)\n",
    "\n",
    "figure(figsize=(30, 10))\n",
    "sns.countplot('rating', data=data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<b>Вывод:</b> для того, чтобы сравнивать двух пользователей или два фильма необходимо нормализовывать оценки, а именно вычитать из них среднюю оценку пользователя или предмета. Также существуют корреляции с некоторыми дополнительными данными.\n",
    "\n",
    "### инсайты\n",
    "1. Были обнаружены пропуски в для некоторых фильмов в колонке tmdb, это связано с тем, что некоторые фильмы есть на imdb, но нет на tmdb. Решение: расставить там -1, так как такого id нет, но мы хотим сохранить информацию о неизвестности + это категориальный признак. Больше пропусков нет, данные хорошие, чистые.\n",
    "2. Были обнаружены корреляции между рейтингом и жанром фильма, но были опровергнуты между рейтингом и годом выпуска.\n",
    "3. Данные подготовлены хорошо и аккуратно, большинство польователей совершали примерно равное число голосов.\n",
    "\n",
    "Найденные факты очень важны, так, к примеру, дополнительные признаки могут улучшить контентные модели, где учитывается информация и ретроспектива прошлых действий. С другой стороны, при решение с помощью коллаборативной фильтрации, очень важно определять близость пользователей или фильмов, поэтому нормализация оценки сделает вычисление схожести приближенной к реальному отношению.\n",
    "\n",
    "### метрика\n",
    "Для задач предсказания оценки используются классические метрики регрессии: `MSE`, `RMSE` и `MAE`. Сейчас стандартом оценки качества является `RMSE`, которая приобрела большую популярность после [Netflix Prize](https://ru.wikipedia.org/wiki/Netflix_Prize). Такая метрика выбрана, так как при решение задачи с помощью линейных алгоритмов, например, `SVD`, нам необходимо минизировать функцию потерь, а среднеквадратичная ошибка хорошо для этого подходит.\n",
    "\n",
    "Как уже было сказано, задача рекомендаций не заканчивается на построение прогнозов оценки, так как дать пользователи фильмы с наибольшей оценкой не всегда верно, именно поэтому иногда используют метрики ранжирования, например, `nDCG`, но так как мы ставим себе задачу именно предсказания оценки, то данный вариант не подходит.\n",
    "\n",
    "Так же, в случаях двух возможных оценок, например, лайк и дизлайк, получаем задачу классификации, где используются уже стандартные метрики классификации: `Precision` и `Recall`. Но такой вариант также не подходит.\n",
    "\n",
    "Таким образом, для оценки качества алгоритма будем использовать метрику `RMSE`. Её нет в пакете `sklearn`, но её легко получить с помощью встроенной `MSE`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from surprise.accuracy import rmse"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### выбор модели\n",
    "Для решения данной задачи воспользуемся готовыми решениями библиотеки [Surprise](surprise.readthedocs.io/en/stable/index.html). Это `scikit` пакет, разработанный специально для рекомендательных систем, в нём есть реализации наиболее популярных алгоритмов: `KNN`, `SVD` и `CoClustering`, а так же предоставленны удобные функции для валидации и подбора параметров. Я воспользуюсь `SVD` алгоритмом, он получил свою известность, благодаря `Netflix Prize`, именно эту модель использовали лидеры соревнований. Сейчас это один из основных применяемых алгоритмов при постраение коллаборативных моделей, он куда более продуманный, чем `user-based` и `item-based` модели. Основным отличием этой модели является возможность выявлять скрытые признаки объектов и интересы пользователей. Например, может так получиться, что на первой координате вектора у каждого пользователя будет стоять число, показывающее, похож ли пользователь больше на мальчика или на девочку, на второй координате — число, отражающее примерный возраст пользователя. У фильма же первая координата будет показывать, интересен ли он больше мальчикам или девочкам, а вторая — какой возрастной группе пользователей он интересен. \n",
    "\n",
    "### предобработка данных\n",
    "Но для начала подготовим данные:\n",
    "- Так как делается упор на коллаборативную фильтрацию, то необходимы номера пользователей и фильмов, а также какие рейтинги были проставлены.\n",
    "- Нормализуем рейтинги путём вычитания из них средней оценки пользователя.\n",
    "- Разобьём данные на тренировочную и тестовую часть, просто отрезать часть выборки будет неправильно, так как алгоритм не должен знать о будующих оценках, так же неправильно убирать пользователя целиком из данных, так как для него тогда не будет похожих пользователей (проблема холодного старта для моделей рекомендательных систем). Поступим так: для каждого пользователя отрежем последние $30%$ оценок, для этого воспользуемся колонкой `timestamp`.\n",
    "- Интерфейс `Surprise` не предпологает выделения целевой переменной, а требует передать данные с $3$ колонками в порядке: $\\textit{user id, item id, ratings}$"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "data = rating.copy()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def train_test_split(X, train_size=0.7, user_col='userId', item_col='movieId',\n",
    "                     rating_col='rating', time_col='timestamp'):\n",
    "    X.sort_values(by=[time_col], inplace=True)\n",
    "    user_ids = X[user_col].unique()\n",
    "    X_train_data = []\n",
    "    X_test_data = []\n",
    "    for user_id in tqdm_notebook(user_ids):\n",
    "        cur_user = X[X[user_col] == user_id]\n",
    "        idx = int(cur_user.shape[0] * train_size)\n",
    "        X_train_data.append(cur_user[[user_col, item_col, rating_col]].iloc[:idx, :].values)\n",
    "        X_test_data.append(cur_user[[user_col, item_col, rating_col]].iloc[idx:, :].values)\n",
    "    X_train = pd.DataFrame(np.vstack(X_train_data), columns=[user_col, item_col, rating_col])\n",
    "    X_test = pd.DataFrame(np.vstack(X_test_data), columns=[user_col, item_col, rating_col])\n",
    "    return X_train, X_test\n",
    "\n",
    "# аккуратно, очень долгий процесс\n",
    "X_train, X_test = train_test_split(data)\n",
    "X_train.shape, X_test.shape"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# разделив 1 раз, сохраним учебную и тренировочную часть в файлы\n",
    "\n",
    "# X_train.to_csv(PATH_TO_DATA + 'train.csv')\n",
    "# X_test.to_csv(PATH_TO_DATA + 'test.csv')\n",
    "\n",
    "X_train = pd.read_csv(PATH_TO_DATA + 'train.csv', index_col='Unnamed: 0',\n",
    "                      dtype={'userId': np.int32, 'movieId': np.int32, 'rating': np.float16})\n",
    "X_test = pd.read_csv(PATH_TO_DATA + 'test.csv', index_col='Unnamed: 0',\n",
    "                      dtype={'userId': np.int32, 'movieId': np.int32, 'rating': np.float16})"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from surprise import Dataset\n",
    "from surprise import Reader\n",
    "\n",
    "reader = Reader(rating_scale=(-5.0, 5.0)) # после нормализации шкала оценок стало от -5.0 до +5.0\n",
    "X_train_surp = Dataset.load_from_df(X_train, reader) # специальный формат данных, поддерживающий деление на фолды\n",
    "X_test_surp = Dataset.load_from_df(X_test, reader)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Кросс-валидация и подбор параметров\n",
    "`SVD` $-$ линейная модель, поэтому для неё характерны стандартные параметры этого семейства алгоритмов:\n",
    "1. Коэффициент регуляризации;\n",
    "2. Колличество шагов градиентного спуска;\n",
    "3. Размер шага градиентного спуска.\n",
    "\n",
    "Ознакомится со всеми параметрами данной модели можно [здесь](http://surprise.readthedocs.io/en/stable/matrix_factorization.html), стоит заметить, что здесь учитывается разброс данных, который был доказан нами ранее, поэтому в модели указывается `biased=True`.\n",
    "\n",
    "Подбор гиперпараметров будет осуществляться с помощью [`GridSearch`](http://surprise.readthedocs.io/en/stable/model_selection.html#surprise.model_selection.search.GridSearchCV), а валидироваться с помощью кросс-валидации по принципу [`LeaveOneOut`](http://surprise.readthedocs.io/en/stable/model_selection.html#surprise.model_selection.split.LeaveOneOut) $-$ у каждого пользователя $1$ рейтинг попадает в тестовый.\n",
    "\n",
    "Мои вычислительные мощности не позволяют обучить так много `SVD` моделей, поэтому я воспользуюсь стандартными настройками, проверю модель на отложенной выборке, а затем сравню с результатом на тестовых данных."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%time\n",
    "from surprise import SVD\n",
    "from surprise.model_selection import LeaveOneOut\n",
    "\n",
    "\n",
    "fold = LeaveOneOut(n_splits=1, random_state=7)\n",
    "model = SVD(n_epochs=10, biased=True, random_state=7, verbose=True)\n",
    "\n",
    "for trainset, valset in fold.split(X_train_surp):\n",
    "    model.fit(trainset)\n",
    "    rmse(model.test(valset))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Создание новых признаков\n",
    "Так как разрабатывалсь коллаборативная модель, то никаких дополнительных признаков <b>не используется</b>, в этом её суть. Однако существуют мощные модели контентной фильтрации, где информация о пользователе и предмете активно используется, один из самых распространённых алгоритмов в этом направление $-$ факторизационные машины.\n",
    "\n",
    "Для таких моделей из наших данных можно вытащить множество признаков, например, как уже было показано $-$ жанр фильма. Другой возможный признак: нам известно какой тег пользователь поставил фильму, также нам известна релевантность тега к фильму. Если релевантность низкая, то, возможно, пользователь не до конца понял фильм и оценка некорректна, таким образом можно штрафовать рейтинг юзера, например:\n",
    "$$\n",
    "    r_{ui}' = \\overline{r_u} + \\textit{rel(u, i)} \\times (r_{ui} - \\overline{r_u})\n",
    "$$\n",
    "То есть при низкой релевантности приближаем оценку пользователя к его средней оценке. Ни в коем случае нельзя удалять записи с низкой релевантностью или по каким-либо другим идейным соображениям, так как данный пользователь смотрел данный фильм, а эту информацию нельзя терять иначе будут создаваться рекомендации для объектов, которые уже посещались.\n",
    "\n",
    "### Прогноз на тестовой или отложенной выборке\n",
    "Так как данные были взяты не из соревнования, то нет возможности смотреть скор на LB, для этого проверяем модель на отложенной выборке, построение которой было описано ранее: для каждого пользователя $30%$ последних оценок попадают в тестовую выборку. Это очень похоже на разбиение при кросс-валидации, но, что логично, берём уже несколько оценко пользователя. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%time\n",
    "model = SVD(n_epochs=10, biased=True, random_state=7, verbose=True)\n",
    "model.fit(X_train_surp.build_full_trainset())"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "predictions = model.test(X_test_surp.build_full_trainset().build_testset())\n",
    "rmse(predictions)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### оценка качества\n",
    "получили хорошую оценку, которая близка к оценке на отложенной выборке, что говорит об устойчивости модели. Так как не было кросс-валидации, то и кривые строит не на чем...\n",
    "\n",
    "### выводы\n",
    "Построение рекомендательных систем важная бизнес-задача, чем дольше пользователь остаётся на сайте, тем больше денег он приносит. В данном проекте был рассмотрен процесс постороения коллаборативной системы, конечно её ещё есть куда улучшить, помимо `SVD` существует множество других алгоритмов. Если продолжать работу именно с этим алгоритмом, то можно двигаться в сторону улучшения модели путём перебора параметров по сетке.\n",
    "\n",
    "Но тем не менее, основная ценность данного решения $-$ существует модель, которая может предсказать будущие оценки пользователя, а так как это линейная модель, то достаточно иметь веса, чтобы их делать. Таким образом мы получили легко интегрируемую функцию, что очень хорошо вписывается в web-сервисы. Также данные модели можно дообучать, что несомненно ещё один плюс в наше решение."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python [default]",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.5.4"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
